查看原文
其他

Bruce Eckel - 详解函数式编程(卷一)

Bruce Eckel 中生代技术 2022-11-14

Bruce Eckel

读完需要

20分钟

速读仅需 5 分钟

布鲁斯 • 埃克尔(Bruce Eckel),C++ 标准委员会的创始成员之一,知名技术顾问,专注于编程语言和软件系统设计方面的研究,常活跃于世界各大顶级技术研讨会。
他自 1986 年以来,累计出版 Thinking in C++、Thinking in Java、On Java 等十余部经典计算机著作,曾多次荣获 Jolt 最佳图书奖(被誉为“软件业界的奥斯卡”),其代表作 Thinking in Java 被译为中文、日文、俄文、意大利文、波兰文、韩文等十几种语言,在世界范围内产生了广泛影响。


函数式编程语言处理代码片段就像处理数据一样简单。尽管 Java 并非函数式语言,但是 Java 8 的 lambda 表达式方法引用允许我们以函数式风格编程。

在计算机时代的早期,内存既稀缺又珍贵。几乎所有人都用汇编语言编程。人们知道编译器,但是很少有人会考虑到编译器的代码生成不够高效——它们会生成很多手写汇编代码时根本不会有的字节!
为了让程序适应有限的内存,程序员往往会在程序执行时修改内存中的代码,使程序做些不一样的事情,来节省代码空间。这就是所谓的自修改代码技术,而且只要程序小到可以只需要少部分人就能维护所有晦涩难懂的汇编代码,让它运行起来多半不是什么大问题。
内存越来越便宜,处理器也越来越快。C语言出现了,而且它被大多数汇编语言程序员认为是 “高级语言”。其他人发现,C语言可以大大提升他们的工作效率。而且在使用C语言时,创建自修改代码仍然不是那么困难。
随着硬件越来越便宜,程序的规模和复杂性也不断增加。单单是让程序工作起来就已经变得很困难了。我们想方设法地让代码更加一致和易懂。就其最纯粹的形式而言,自修改代码已被证明是非常糟糕的想法,因为很难完全确定这些代码是在做什么。测试它们也很困难——我们是在测试输出,测试还在改变的代码,测试修改的过程,还是其他呢?
然而,使用代码以某种方式操纵其他代码这种想法仍然非常吸引人,只要有某种方法使其更加安全即可。
从代码创建、维护和可靠性的角度来看,这个想法很有吸引力。试想一下,我们不是从零开始编写大量代码,而是从现有的、可以理解的、经过良好测试的、可靠的小代码片段开始。然后将它们组合在一起,创建新的代码。这难道不会让我们更有效率,同时也能创造出更稳健的代码吗?
这就是函数式编程(functional programming, FP)的意义所在。通过整合现有代码来产生新的功能,而不是从零开始编写所有内容,由此我们会得到更可靠的代码,而且实现起来更快。这个理论看起来是成立的,至少在某些情况下如此。在发展过程中,函数式语言设计出了优秀的语法,一些非函数式语言也借用了。
我们也可以这样认为:

面向对象编程抽象数据,而函数式编程抽象行为。

纯函数式语言在安全方面做出了更多努力。它规定了额外的约束条件,即所有的数据必须是不可变的:设置一次,永不改变。函数会接受值,然后产生新值,但是绝对不会修改自身之外的任何东西(包括其参数或该函数作用域之外的元素)。有了这一保证,我们知道不会再有任何由所谓的“副作用”引起的bug,因为函数只是创建并返回了一个结果,别的什么都没做。
更妙的是,“不可变对象和无副作用”这一编程范式解决了并行编程(当程序的不同部分同时在多个处理器上运行时)中最基本和最棘手的问题之一——“可变的共享状态”问题。“可变的共享状态”意味着,运行在不同处理器上的代码的不同部分,可能会同时尝试修改同一块内存(谁会成功?没有人知道)。如果函数绝对不会修改现有值,而只是生成新值——这是纯函数式语言的定义——那么就不可能存在对内存的竞争。因此,纯函数式语言经常被当作并行编程问题的解决方案,当然也有其他可行的解决方案。
请注意,函数式语言背后还有许多动机,而描述这些动机可能会令人迷惑。这往往取决于不同的视角。从“函数式语言是为并行编程准备的”,到“代码可靠性”,再到“代码创建和库复用”,[0],原因各不相同。还要记住,支持函数式编程的理由,特别是程序员能以更快的速度创建更稳健的代码,至少在一定程度上还只是假设。我们已经看到了一些好的结果,[1]但是我们还没有证明纯函数式语言就是解决这类编程问题的最佳方法。
来自函数式编程的理念值得纳入非函数式编程语言中。例如Python就是这么做的,而且从中受益匪浅。Java 8将来自函数式编程的理念加入了自己的特性中,本章接下来会介绍。

1


   

旧方式与新方式

通常情况下,方法会根据所传递的数据产生不同的结果。如果想让一个方法在每次调用时都有不同的表现呢?如果将代码传递给方法,就可以控制其行为。
以前的做法是,创建一个对象,让它的一个方法包含所需行为,然后将这个对象传递给我们想控制的方法。下面的示例演示了这一点,然后增加了Java 8的实现方式:方法引用和lambda表达式。
// functional/Strategize.java

interface Strategy {
  String approach(String msg);
}

class Soft implements Strategy {
  @Override
  public String approach(String msg) {
    return msg.toLowerCase() + "?";
  }
}

class Unrelated {
  static String twice(String msg) {
    return msg + " " + msg;
  }
}

public class Strategize {
  Strategy strategy;
  String msg;
  Strategize(String msg) {
    strategy = new Soft();                // [1]
    this.msg = msg;
  }
  void communicate() {
    System.out.println(strategy.approach(msg));
  }
  void changeStrategy(Strategy strategy) {
    this.strategy = strategy;
  }
  public static void main(String[] args) {
    Strategy[] strategies = {
      new Strategy() {                    // [2]
        public String approach(String msg) {
          return msg.toUpperCase() + "!";
        }
      },
      msg -> msg.substring(05),         // [3]
      Unrelated::twice                    // [4]
    };
    Strategize s = new Strategize("Hello there");
    s.communicate();
    for(Strategy newStrategy : strategies) {
      s.changeStrategy(newStrategy);      // [5]
      s.communicate();                    // [6]
    }
  }
}
/* 输出:
hello there?
HELLO THERE!
Hello
Hello there Hello there
*/

Strategy提供了接口,功能是通过其中唯一的approach()方法来承载的。通过创建不同的Strategy对象,我们可以创建不同的行为。
传统上,我们通过定义一个实现了Strategy接口的类来完成这种行为,比如Soft。
[1] 在Strategize中可以看到,Soft是默认的策略,因为它是在构造器中指定的。
[2] 更简洁、自然的方式是创建一个匿名内部类。这样仍然会存在一定数量的重复代码,而且我们总是要花点功夫才能明白这里是在使用匿名内部类。
[3] 这是Java 8的lambda表达式,突出的特点是用箭头->将参数和函数体分隔开来。箭头右边是从lambda返回的表达式。这和类定义以及匿名内部类实现了同样的效果,但是代码要少得多。
[4] 这是Java 8的方法引用,突出的特点是::。::的左边是类名或对象名,右边是方法名,但是没有参数列表。
[5] 在使用了默认的Soft策略之后,我们遍历数组中的所有策略,并使用changeStrategy()方法将每个策略放入s中。
[6] 现在,每次调用communicate()都会产生不同的行为,这取决于此时所使用的策略“代码对象”。我们传递了行为,而不只是传递数据。
[2]
在Java 8之前,我们已经能够通过[1]和[2]传递功能。然而,这种语法并不方便,所以只在不得已的情况下才会使用。方法引用和lambda表达式改变了这种状况,我们想传递功能的时候就可以传递。

2


   

lambda 表达式

lambda表达式是使用尽可能少的语法编写的函数定义。
lambda表达式产生的是函数,而不是类。在Java虚拟机(JVM)上,一切都是类,所以幕后会有各种各样的操作,让lambda看起来像函数。但是作为程序员,我们可以开心地假装它们“就是函数”。
lambda表达式的语法尽可能宽松,而又恰好使其容易编写和使用。
我们在Strategize.java中已经见过一个lambda表达式,但是还有其他语法变种:
// functional/LambdaExpressions.java

interface Description {
  String brief();
}

interface Body {
  String detailed(String head);
}

interface Multi {
  String twoArg(String head, Double d);
}

public class LambdaExpressions {

  static Body bod = h -> h + " No Parens!";      // [1]

  static Body bod2 = (h) -> h + " More details"// [2]

  static Description desc = () -> "Short info";  // [3]

  static Multi mult = (h, n) -> h + n;           // [4]

  static Description moreLines = () -> {         // [5]
    System.out.println("moreLines()");
    return "from moreLines()";
  };

  public static void main(String[] args) {
    System.out.println(bod.detailed("Oh!"));
    System.out.println(bod2.detailed("Hi!"));
    System.out.println(desc.brief());
    System.out.println(mult.twoArg("Pi! "3.14159));
    System.out.println(moreLines.brief());
  }
}
/* 输出:
Oh! No Parens!
Hi! More details
Short info
Pi! 3.14159
moreLines()
from moreLines()
*/

我们从3个接口开始,每个接口中都有一个方法(你很快就能理解其意义了)。不过为了演示lambda表达式的语法,每个方法的参数数量不同。
任何lambda表达式的基本语法如下所示:
参数;
后面跟->,你可以将其读作“产生”(produces);
->后面的都是方法体。
[1] 只有一个参数,可以只写这个参数,不写括号。不过这是一种特殊情况。
[2] 通常情况是用括号将参数包裹起来。为了一致性,在单个参数时也可以使用括号,尽管这并不常见。
[3] 在没有参数的情况下,必须使用括号来指示空的参数列表。
[4] 在有多个参数的情况下,将它们放在使用括号包裹起来的参数列表内。
到目前为止,所有lambda表达式的方法体都是一行。方法体中表达式的结果会自动成为lambda表达式的返回值,这里使用return关键字是不合法的。这是lambda表达式简化描述功能的语法的又一种方式。
[5] 如果lambda表达式需要多行代码,则必须将这些代码行放到花括号中。这种情况下又需要使用return从lambda表达式生成一个值了。
与匿名内部类相比,lambda表达式生成的代码通常可读性更好,所以本书中会尽可能使用它们。

递归

递归意味着一个函数调用了自身。在Java中也可以编写递归的lambda表达式,但是有一点要注意:这个lambda表达式必须被赋值给一个静态变量或一个实例变量,否则会出现编译错误。我们分别创建一个示例来说明每种情况。
这两个示例都使用了一个同样的接口,其方法接受int参数,并返回int:
// functional/IntCall.java

interface IntCall {
  int call(int arg);
}
整数n的阶乘是所有小于等于n的正整数的乘积。阶乘函数是一个常见的递归示例:
// functional/RecursiveFactorial.java

public class RecursiveFactorial {
  static IntCall fact;
  public static void main(String[] args) {
    fact = n -> n == 0 ? 1 : n * fact.call(n - 1);
    for(int i = 0; i <= 10; i++)
      System.out.println(fact.call(i));
  }
}
/* 输出:
1
1
2
6
24
120
720
5040
40320
362880
3628800
*/

这里fact是一个静态变量。注意这里使用了三元选择操作符。递归函数会不断调用自身,直到n == 0。所有的递归函数都有某种停止条件,否则将无限递归,直到耗尽栈空间并产生异常。
请注意,不能在定义的时候像这样来初始化fact:
static IntCall fact = n -> n == 0 ? 1 : n * fact.call(n - 1);
尽管这样的期望非常合理,但是对于Java编译器而言处理起来太复杂了,所以会产生编译错误。
可以用一个递归的lambda表达式来实现斐波那契数列(Fibonacci sequence),这次使用的是实例变量,用构造器来初始化:
// functional/RecursiveFibonacci.java

public class RecursiveFibonacci {
  IntCall fib;
  RecursiveFibonacci() {
    fib = n -> n == 0 ? 0 :
               n == 1 ? 1 :
               fib.call(n - 1) + fib.call(n - 2);
  }
  int fibonacci(int n) return fib.call(n); }
  public static void main(String[] args) {
    RecursiveFibonacci rf = new RecursiveFibonacci();
    for(int i = 0; i <= 10; i++)
      System.out.println(rf.fibonacci(i));
  }
}
/* 输出:
0
1
1
2
3
5
8
13
21
34
55
*/

斐波那契数列从第三项开始,每一项都等于前两项之和。

3


   

方法引用

Java 8方法引用指向的是方法,没有之前Java版本的历史包袱。方法引用是用类名或对象名,后面跟::[3],然后跟方法名:
// functional/MethodReferences.java

interface Callable {                        // [1]
  void call(String s);
}

class Describe {
  void show(String msg) {                   // [2]
    System.out.println(msg);
  }
}

public class MethodReferences {
  static void hello(String name) {          // [3]
    System.out.println("Hello, " + name);
  }
  static class Description {
    String about;
    Description(String desc) { about = desc; }
    void help(String msg) {                 // [4]
      System.out.println(about + " " + msg);
    }
  }
  static class Helper {
    static void assist(String msg) {        // [5]
      System.out.println(msg);
    }
  }
  public static void main(String[] args) {
    Describe d = new Describe();
    Callable c = d::show;                   // [6]
    c.call("call()");                       // [7]

    c = MethodReferences::hello;            // [8]
    c.call("Bob");

    c = new Description("valuable")::help;  // [9]
    c.call("information");

    c = Helper::assist;                     // [10]
    c.call("Help!");
  }
}
/* 输出:
call()
Hello, Bob
valuable information
Help!
*/

[1] 我们从只包含一个方法的接口开始(还是那样,一会儿你就知道它的重要性了)。
[2] show()的签名(参数类型和返回类型)和Callable中call()的签名一致。
[3] hello()的签名也和call()一致。
[4] help()是静态内部类中的一个非静态方法。
[5] assist()是静态内部类中的一个静态方法。
[6] 我们将Describe对象的一个方法引用赋值给了一个Callable,Callable中没有show()方法,只有一个call()方法。然而,Java似乎对这种看似奇怪的赋值并没有意见,因为这个方法引用的签名和Callable中的call()方法一致。
[7] 现在可以通过调用call()来调用show(),因为Java将call()映射到了show()上。
[8] 这是一个静态方法引用。
[9] 这是[6]的另一个版本:对某个活跃对象上的方法的方法引用,有时叫作“绑定方法引用”(bound method reference)。
[10] 最后,获得静态内部类中的静态方法的方法引用,看起来就像在[8]处的外部类版本。

这还不是一个非常详尽的示例,我们很快就会看到方法引用的所有变种。

3.1


   

Runnable

Runnable接口在java.lang包中,所以不需要import。它也遵从特殊的单方法接口格式:其run()方法没有参数,也没有返回值。所以我们可以将lambda表达式或方法引用用作Runnable:
// functional/RunnableMethodReference.java
// 使用Runnable接口的方法引用

class Go {
  static void go() {
    System.out.println("Go::go()");
  }
}

public class RunnableMethodReference {
  public static void main(String[] args) {

    new Thread(new Runnable() {
      public void run() {
        System.out.println("Anonymous");
      }
    }).start();

    new Thread(
      () -> System.out.println("lambda")
    ).start();

    new Thread(Go::go).start();
  }
}
/* 输出:
Anonymous
lambda
Go::go()
*/

Thread对象接受一个Runnable作为其构造器参数,它有一个start()方法会调用run()。注意,示例代码中的3种情形,只有匿名内部类需要提供名为run()的方法。

3.2


   

未绑定方法引用

未绑定方法引用(unbound method reference)指的是尚未关联到某个对象的普通(非静态)方法。对于未绑定引用,必须先提供对象,然后才能使用:
// functional/UnboundMethodReference.java
// 未绑定对象的方法引用

class X {
  String f() return "X::f()"; }
}

interface MakeString {
  String make();
}

interface TransformX {
  String transform(X x);
}

public class UnboundMethodReference {
  public static void main(String[] args) {
    // MakeString ms = X::f;                // [1]
    TransformX sp = X::f;
    X x = new X();
    System.out.println(sp.transform(x));    // [2]
    System.out.println(x.f()); // 效果相同
  }
}
/* 输出:
X::f()
X::f()
*/

到目前为止,我们看到的对方法的引用,与其关联接口的签名是相同的。在[1]处,我们尝试对X中的f()做同样的事情,将其赋值给MakeString。编译器会报错,提示“无效方法引用”(invalid method reference),即使make()的签名和f()相同。问题在于,这里事实上还涉及另一个(隐藏的)参数:我们的老朋友this。如果没有一个可供附着的X对象,就无法调用f()。因此,X::f代表的是一个未绑定方法引用,因为它没有“绑定到”某个对象。
为解决这个问题,我们需要一个X对象,所以我们的接口事实上还需要一个额外的参数,如TransformX中所示。如果将X::f赋值给一个TransformX,Java会开心地接受。我们必须再做一次心理调节:在未绑定引用的情况下,函数式方法(接口中的单一方法)的签名与方法引用的签名不再完全匹配。这样做有一个很好的理由,那就是我们需要一个对象,让方法在其上调用。
在[2]处的结果有点儿像“脑筋急转弯”。我们接受了未绑定引用,然后以X为参数在其上调用了transform(),最终以某种方式调用了x.f()。Java知道它必须接受第一个参数,事实上就是this,并在它的上面调用该方法。
如果方法有更多参数,只要遵循第一个参数取的是this这种模式:
// functional/MultiUnbound.java
// 有多个参数的未绑定方法

class This {
  void two(int i, double d) {}
  void three(int i, double d, String s) {}
  void four(int i, double d, String s, char c) {}
}

interface TwoArgs {
  void call2(This athis, int i, double d);
}

interface ThreeArgs {
  void call3(This athis, int i, double d, String s);
}

interface FourArgs {
  void call4(
    This athis, int i, double d, String s, char c)
;
}

public class MultiUnbound {
  public static void main(String[] args) {
    TwoArgs twoargs = This::two;
    ThreeArgs threeargs = This::three;
    FourArgs fourargs = This::four;
    This athis = new This();
    twoargs.call2(athis, 113.14);
    threeargs.call3(athis, 113.14"Three");
    fourargs.call4(athis, 113.14"Four"'Z');
  }
}
为了说明问题,这里把类命名为This, 把函数式方法的第一个参数命名为athis,但是你应该选择其他名字,以防在生产代码中引起混淆。

3.3


   

构造器方法引用

我们也可以捕获对某个构造器的引用,之后通过该引用来调用那个构造器。
// functional/CtorReference.java

class Dog {
  String name;
  int age = -1// For "unknown"
  Dog() { name = "stray"; }
  Dog(String nm) { name = nm; }
  Dog(String nm, int yrs) { name = nm; age = yrs; }
}

interface MakeNoArgs {
  Dog make();
}

interface Make1Arg {
  Dog make(String nm);
}

interface Make2Args {
  Dog make(String nm, int age);
}

public class CtorReference {
  public static void main(String[] args) {
    MakeNoArgs mna = Dog::new;        // [1]
    Make1Arg m1a = Dog::new;          // [2]
    Make2Args m2a = Dog::new;         // [3]

    Dog dn = mna.make();
    Dog d1 = m1a.make("Comet");
    Dog d2 = m2a.make("Ralph"4);
  }
}
Dog有3个构造器,几个函数式接口中的make()方法反映了构造器的参数列表(make()方法可以有不同的名字)。
注意我们在[1]、[2]和[3]中是如何使用Dog::new的。所有这3个构造器都只有一个名字:::new。但是在每种情况下,构造器引用被赋值给了不同的接口,编译器可以从接口来推断使用哪个构造器。
编译器可以看到,调用这里的函数式接口方法(这个示例中的make())意味着调用构造器。

4


   

函数式接口

方法引用和lambda表达式都必须赋值,而这些赋值都需要类型信息,让编译器确保类型的正确性。尤其是lambda表达式,又引入了新的要求。考虑如下代码:
x -> x.toString()
我们看到返回类型必须是String,但是x是什么类型呢?因为lambda表达式包含了某种形式的类型推断(编译器推断出类型的某些信息,而不需要程序员显式指定),所以编译器必须能够以某种方式推断出x的类型。
下面是第二个示例:
(x, y) -> x + y
现在x和y可以是支持+操作符的任何类型,包括两种不同的数值类型,或者是一个String和某个能够自动转换为String的其他类型(这包括了大部分类型)。但是在lambda表达式被赋值之后,编译器就必须确定x和y的精确类型并生成正确的代码了。
同样的情况也适用于方法引用。假设想把
System.out::println
传递给正在编写的一个方法,那么方法的参数应该是什么类型呢?
为解决这个问题,Java 8引入了包含一组接口的java.util.function,这些接口是lambda表达式和方法引用的目标类型。每个接口都只包含一个抽象方法,叫作函数式方法。
当编写接口时,这种“函数式方法”模式可以使用@FunctionalInterface注解来强制实施:
// functional/FunctionalAnnotation.java

@FunctionalInterface
interface Functional {
  String goodbye(String arg);
}

interface FunctionalNoAnn {
  String goodbye(String arg);
}

/*
@FunctionalInterface
interface NotFunctional {
  String goodbye(String arg);
  String hello(String arg);
}
产生报错信息:
NotFunctional is not a functional interface
multiple non-overriding abstract methods
found in interface NotFunctional
*/


public class FunctionalAnnotation {
  public String goodbye(String arg) {
    return "Goodbye, " + arg;
  }
  public static void main(String[] args) {
    FunctionalAnnotation fa =
      new FunctionalAnnotation();
    Functional f = fa::goodbye;
    FunctionalNoAnn fna = fa::goodbye;
    // Functional fac = fa; // 不兼容
    Functional fl = a -> "Goodbye, " + a;
    FunctionalNoAnn fnal = a -> "Goodbye, " + a;
  }
}
@FunctionalInterface注解是可选的。Java会将main()中的Functional和FunctionalNoAnn都看作函数式接口。在NotFunctional的定义中,我们可以看到@FunctionalInterface的价值:如果接口中的方法多于一个,则会产生一条编译错误信息。
仔细看一下f和fna的定义中发生了什么。Functional和FunctionalNoAnn定义了接口。然而被赋值给它们的只是方法goodbye。首先,goodbye只是一个方法,而不是类。其次,它甚至不是实现了这里定义的某个接口的类中的方法。这是Java 8增加的一个小魔法:如果我们将一个方法引用或lambda表达式赋值给某个函数式接口(而且类型可以匹配),那么Java会调整这个赋值,使其匹配目标接口。而在底层,Java编译器会创建一个实现了目标接口的类的实例,并将我们的方法引用或lambda表达式包裹在其中。
使用了@FunctionalInterface注解的接口也叫作单一抽象方法(Single Abstract Method, SAM)类型。
尽管FunctionalAnnotation确实符合Functional的模式,但是如果我们像在fac的定义中那样,试图把FunctionalAnnotation直接赋值给一个Functional,Java是不允许的。这也符合我们的预期,因为它没有显式地实现Functional。唯一令人惊喜的是,Java 8允许我们将函数赋值给接口,使得语法更好、更简单。
java.util.function旨在创建一套足够完备的目标接口,这样一般情况下我们就不需要定义自己的接口了。接口的数量有了明显的增长,这主要是由于基本类型的缘故。如果理解命名模式,一般来说通过名字就可以了解特定的接口是做什么的。下面是基本的命名规则。
如果接口只处理对象,而非基本类型,那就会用一个直截了当的名字,像Function、Consumer和Predicate等。参数类型会通过泛型添加。
如果接口接受一个基本类型的参数,则会用名字的第一部分来表示,例如LongConsumer、DoubleFunction和IntPredicate等接口类型。基本的Supplier类型是个例外。
如果接口返回的是基本类型的结果,则会用To来表示,例如ToLongFunction和IntToLongFunction。
如果接口返回的类型和参数类型相同,则会被命名为Operator。UnaryOperator用于表示一个参数,BinaryOperator用于表示两个参数。
如果接口接受一个参数并返回boolean,则会被命名为Predicate。
如果接口接受两个不同类型的参数,则名字中会有一个Bi(比如BiPredicate)。
表13-1中描述了java.util.function中的目标类型(例外情况会指出来),帮助你推断所需要的函数式接口:
表13-1
特点
函数式方法命名 a
用法
没有参数;没有返回值
Runnable (java.lang) run()
Runnable
没有参数;可以返回任何类型
Supplierget()getAstype()
Supplier BooleanSupplier IntSupplier LongSupplier DoubleSupplier
没有参数;可以返回任何类型
Callable (java.util.concurrent) call()
Callable
一个参数;没有返回值
Consumeraccept()
Consumer IntConsumer LongConsumer DoubleConsumer
两个参数的Consumer
BiConsumeraccept()
BiConsumer,u>
两个参数的Consumer;第一个参数是引用,第二个参数是基本类型
ObjtypeConsumeraccept()
ObjIntConsumer ObjLongConsumer ObjDoubleConsumer
一个参数;返回值为不同类型
Functionapply()Totype & typeTotype: applyAstype()
Function ,r>IntFunction LongFunction DoubleFunction ToIntFunction ToLongFunction ToDoubleFunction IntToLongFunction IntToDoubleFunction LongToIntFunction LongToDoubleFunction DoubleToIntFunction DoubleToLongFunction
一个参数;返回值为相同类型
UnaryOperatorapply()
UnaryOperator IntUnaryOperator LongUnaryOperator DoubleUnaryOperator
两个相同类型的参数;返回值也是相同类型
BinaryOperatorapply()
BinaryOperatorIntBinaryOperatorLongBinaryOperatorDoubleBinaryOperator
两个相同类型的参数;返回int
Comparator (java.util) compare()
Comparator
两个参数;返回boolean
Predicatetest()
Predicate BiPredicate ,u>IntPredicate LongPredicate DoublePredicate
基本类型的参数;返回值也是基本类型
typeTotypeFunctionapplyAstype()
IntToLongFunction IntToDoubleFunction LongToIntFunction LongToDoubleFunction DoubleToIntFunction DoubleToLongFunction
两个参数;不同类型
Bi+操作名(方法名会变化)
BiFunction ,u,r>BiConsumer ,u>BiPredicate,u>ToIntBiFunction ,u>ToLongBiFunction ,u>ToDoubleBiFunction,u>
a表中的“type”会根据具体情况替换为相应类型名。——译者注
可以看到,java.util.function在创建时做了一些设计选择。例如,为什么没有IntComparator、LongComparator和DoubleComparator?有BooleanSupplier,但是没有其他代表Boolean的接口。有通用的 BiConsumer,但是没有用于int、long和double等所有变种的BiConsumer(我其实能够认同设计者的选择)。这些是疏忽,还是有人认为那些变种被用到的机会太少?他们又是如何得出这样的结论的呢?
还可以看到,基本类型给Java增加了多少复杂性。出于对效率的考虑,它们被包含在了Java的第一个版本中,而效率问题很快就得以缓解了。现在,在这门语言的生命周期内,我们只能忍受一个糟糕的语言设计选择所带来的后果。
下面这个示例列举了可用于lambda表达式的所有不同的Function变种:
// functional/FunctionVariants.java
import java.util.function.*;

class Foo {}

class Bar {
  Foo f;
  Bar(Foo f) { this.f = f; }
}

class IBaz {
  int i;
  IBaz(int i) {
    this.i = i;
  }
}

class LBaz {
  long l;
  LBaz(long l) {
    this.l = l;
  }
}

class DBaz {
  double d;
  DBaz(double d) {
    this.d = d;
  }
}

public class FunctionVariants {
  static Functionf1 = f -> new Bar(f);
  static IntFunctionf2 = i -> new IBaz(i);
  static LongFunctionf3 = l -> new LBaz(l);
  static DoubleFunctionf4 = d -> new DBaz(d);
  static ToIntFunctionf5 = ib -> ib.i;
  static ToLongFunctionf6 = lb -> lb.l;
  static ToDoubleFunctionf7 = db -> db.d;
  static IntToLongFunction f8 = i -> i;
  static IntToDoubleFunction f9 = i -> i;
  static LongToIntFunction f10 = l -> (int)l;
  static LongToDoubleFunction f11 = l -> l;
  static DoubleToIntFunction f12 = d -> (int)d;
  static DoubleToLongFunction f13 = d -> (long)d;

  public static void main(String[] args) {
    Bar b = f1.apply(new Foo());
    IBaz ib = f2.apply(11);
    LBaz lb = f3.apply(11);
    DBaz db = f4.apply(11);
    int i = f5.applyAsInt(ib);
    long l = f6.applyAsLong(lb);
    double d = f7.applyAsDouble(db);
    l = f8.applyAsLong(12);
    d = f9.applyAsDouble(12);
    i = f10.applyAsInt(12);
    d = f11.applyAsDouble(12);
    i = f12.applyAsInt(13.0);
    l = f13.applyAsLong(13.0);
  }
}

我们尝试让这些lambda表达式生成能匹配签名的最简单的代码。在某些情况下,必须执行类型转换,否则编译器会报截断错误。

main()中的每条测试演示了Function接口中不同种类的apply方法。每个方法都会调用其关联的lambda表达式。
方法引用有自己的魔法:
// functional/MethodConversion.java
import java.util.function.*;

class In1 {}
class In2 {}

public class MethodConversion {
  static void accept(In1 i1, In2 i2) {
    System.out.println("accept()");
  }
  static void someOtherName(In1 i1, In2 i2) {
    System.out.println("someOtherName()");
  }
  public static void main(String[] args) {
    BiConsumerbic;

    bic = MethodConversion::accept;
    bic.accept(new In1(), new In2());

    bic = MethodConversion::someOtherName;
    // bic.someOtherName(new In1(), new In2()); // 不行
    bic.accept(new In1(), new In2());
  }
}
/* 输出:
accept()
someOtherName()
*/

查阅BiConsumer的文档,会看到它的函数式方法是accept()。确实,如果将我们的方法命名为accept(),它可以用作方法引用。但如果给它起个完全不同的名字,比如someOtherName(),只要参数类型和返回类型与BiConsumeraccept()相同,也是没问题的。

因此,当使用函数式接口时,名字并不重要,重要的只有参数类型和返回类型。Java会将我们起的名字映射到接口的函数式方法上。要调用我们的方法,就要调用这个函数式方法的名字(在这个示例中是accept()),而不是我们的方法的名字。
现在来看一下可用于方法引用的所有基于类的函数式接口(也就是不涉及基本类型的那些)。这次我们仍然创建了能匹配函数式接口签名的最简单方法:
// functional/ClassFunctionals.java
import java.util.*;
import java.util.function.*;

class AA {}
class BB {}
class CC {}

public class ClassFunctionals {
  static AA f1() return new AA(); }
  static int f2(AA aa1, AA aa2) return 1; }
  static void f3(AA aa) {}
  static void f4(AA aa, BB bb) {}
  static CC f5(AA aa) return new CC(); }
  static CC f6(AA aa, BB bb) return new CC(); }
  static boolean f7(AA aa) return true; }
  static boolean f8(AA aa, BB bb) return true; }
  static AA f9(AA aa) return new AA(); }
  static AA f10(AA aa1, AA aa2) return new AA(); }
  public static void main(String[] args) {
    Suppliers = ClassFunctionals::f1;
    s.get();
    Comparatorc = ClassFunctionals::f2;
    c.compare(new AA(), new AA());
    Consumercons = ClassFunctionals::f3;
    cons.accept(new AA());
    BiConsumerbicons = ClassFunctionals::f4;
    bicons.accept(new AA(), new BB());
    Functionf = ClassFunctionals::f5;
    CC cc = f.apply(new AA());
    BiFunctionbif = ClassFunctionals::f6;
    cc = bif.apply(new AA(), new BB());
    Predicatep = ClassFunctionals::f7;
    boolean result = p.test(new AA());
    BiPredicatebip = ClassFunctionals::f8;
    result = bip.test(new AA(), new BB());
    UnaryOperatoruo = ClassFunctionals::f9;
    AA aa = uo.apply(new AA());
    BinaryOperatorbo = ClassFunctionals::f10;
    aa = bo.apply(new AA(), new AA());
  }
}

注意,每个方法的名称可以是任意的(比如f1()f2()等),但是正如我们刚才看到的,一旦方法引用被赋值给某个函数式接口,就可以调用与这个接口关联的函数式方法了。在这个示例中,这些方法是get()compare()accept()apply()test()

4.1


   

带有更多参数的函数式接口

java.util.function中的接口毕竟是有限的。比如有一个BiFunction, 但也仅此而已了。如果我们需要用于3个参数的函数接口呢?因为那些接口相当直观,所以看一下Java库的源代码,然后编写我们自己的接口也很容易:
// functional/TriFunction.java

@FunctionalInterface
public interface TriFunction{
    R apply(T t, U u, V v);
}

,>

下面用一个简短的测试来验证它是否能工作:
// functional/TriFunctionTest.java

public class TriFunctionTest {
  static int f(int i, long l, double d) return 99; }
  public static void main(String[] args) {
    TriFunctiontf =
      TriFunctionTest::f;
    tf = (i, l, d) -> 12;
  }
}

方法引用和lambda表达式我们都做了测试。


4.2


   

解决缺乏基本类型函数式接口的问题

我们重新研究一下BiConsumer,看看如何创建java.util.function中没有提供的,涉及int、long和double等基本类型的函数式接口:
// functional/BiConsumerPermutations.java
import java.util.function.*;

public class BiConsumerPermutations {
  static BiConsumerbicid = (i, d) ->
    System.out.format("%d, %f%n", i, d);
  static BiConsumerbicdi = (d, i) ->
    System.out.format("%d, %f%n", i, d);
  static BiConsumerbicil = (i, l) ->
    System.out.format("%d, %d%n", i, l);
  public static void main(String[] args) {
    bicid.accept(4711.34);
    bicdi.accept(22.4592);
    bicil.accept(111L);
  }
}
/* 输出:
47, 11.340000
92, 22.450000
1, 11
*/

为了显示效果更佳,这里使用了System.out.format(),它和System.out.println()很像,但是提供了更多显示选项。%f表示将d当作一个浮点值,而%d表示i是一个整型值。此处也可以包含空格,而且除非我们写上%n,否则不会增加新行。它也接受用传统的\n来表示换行,但是%n可以自动跨平台,这是使用format()的另一个理由。
这个示例简单地使用了适合的包装器类型,而自动装箱和自动拆箱会处理基本类型及其包装器类型之间的来回转换。我们也可以在其他函数式接口中使用包装器类型,比如Function,而不是提前定义好各种变种:
// functional/FunctionWithWrapped.java
import java.util.function.*;

public class FunctionWithWrapped {
  public static void main(String[] args) {
    Functionfid = i -> (double)i;
    IntToDoubleFunction fid2 = i -> i;
  }
}
,>
如果没有使用类型转换,则会出现编译错误:“Integer无法转换为Double(Integer cannot be converted to Double)。”然而IntToDoubleFunction版本就没有这样的问题。Java库中IntToDoubleFunction的代码是这样的:
@FunctionalInterface
public interface IntToDoubleFunction {
  double applyAsDouble(int value);
}
因为直接编写Function,double>就能得到可行的方案,所以很明显,存在函数式接口的基本类型变种的唯一原因,就是防止在传递参数和返回结果时涉及自动装箱和自动拆箱。也就是说,为了性能。
似乎可以有把握地猜测,之所以有些函数式接口类型有定义,而有些没有,是根据预计的使用频率决定的。
当然,如果因为缺少基本类型的函数式接口,导致性能真的成了问题,我们也可以很容易地编写自己的接口(参考Java库源代码),不过这成为性能瓶颈的可能性极小。(未完待续)

本书特色

查漏宝典:涵盖Java关键特性的设计原理和应用方法

避坑指南:以产业实践的得失为鉴,指明Java开发者不可不知的设计陷阱

经典普适:值得不同层次的Java开发者反复研读

专家领读:4位一线业务专家、知名作译者帮你拆解书中难点,总结Java开发精要

 

值得一提的是,为了帮助新手加深理解,出版方邀请了4位从业10年以上知名作译者DDD 专家张逸、服务端专家梁桂钊、软件系统架构专家王前明、译者陈德伟)为本书录制【精讲视频】和【导读指南】,该视频已在B站和图灵社区发布,感兴趣的朋友可以去看看。


读者福利    

618特别活动,基础卷限时5折,800多页软精装到手价64.9!!

  

618限时5折专享

 

想购买全套装书的读者,1400多页软精装到手价160

 

限量套装,售完为止

 


往期推荐

如葑:阿里云原生网关Envoy Gateway实践

如何用研发效能搞垮一个团队

他教全世界程序员怎么写好代码,答案写在这里!

研发效能提升的实践框架、模式与反模式

聊聊大中型公司都热衷于造轮子的故事

构建健壮的分布式系统

被滥用的“架构师”!


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存